iOS 消息转发

在日常开发的过程中,常常会遇到’reason: -[类名 方法名]: unrecognized selector sent to instance 0x…’的异常提示,从字面意思上看也大概知道产生这种的原因是向某个对象发送了没有实现的消息,也就是对象调用了某个没有定义的方法。解决这种bug很简单,直接去该对象所在的类文件中添加方法实现(即使是空实现也可以),问题就解决了。虽然问题解决了,但不明白原理,为什么仅仅因为一个方法没实现,程序就会报异常甚至闪退呢?所写的代码从编译到报异常过程的背后到底发生了什么?其实这背后的原理就是Objective-C的消息转发机制。下面来具体说说:

1.在OC中,对象调用方法其实就是向对象发送消息。由于OC采用的是”动态绑定机制“,所以所调用的方法直到运行期才能确定。

一个对象接收到消息后,会从当前类的方法列表或者父类的方法列表查找到对应的方法实现(IMP)来处理该消息。大致流程如下:

a.通过NSObject的isa指针找到对应的Class;
b.在Class的方法列表中找到对应的selector;
c.如果在当前Class中未能找到selector则往父类的方法列表中继续查找;
d.如果能找到对应的selector则去执行对象的方法实现(IMP);

在上述流程中如果不能找对对应的selector时,这时候就会进入消息转发机制。消息转发机制可分为两个阶段,在这两个阶段中,有3次机会来处理之前未能处理selector,越往后所花费的代价将越大,处理的灵活程度也就越高。如下图所示:

2.上图中的过程可以分为两个阶段来描述:

a.动态解析阶段

在该阶段中,可以动态的为类添加一个方法,从而让动态添加的方法来处理之前未能处理的消息。可重写类的以下方法:

+ (BOOL)resolveInstanceMethod:(SEL)sel //针对类的实例方法
+ (BOOL)resolveClassMethod:(SEL)sel    //针对类的静态方法

SEL就是未能处理的selector,返回值为BOOL表示是否增加了新的方法来处理该selector。在当前阶段处理未知selector的前提是,你已经准备好了新的方法来处理该selector,等着运行时将方法动态添加到类中即可,该阶段一般用来实现@dynamic属性。

b.消息转发阶段

如果在解析阶段未能处理未知的selector,运行时将进入消息的转发,在这个阶段,我们可以将未知的selector转发给其他对象来处理。运行时提供两次机会,来做消息的转发,第一次是重写以下方法:

- (id)forwardingTargetForSelector:(SEL)aSelector

该方法的SEL就是未能处理的selector,返回值类型为id用来指定selector处理的对象,运行时将会把未能处理的SEL转发给该对象。该阶段我们可以将selector转发到类中的其他对象来处理,从而实现代理模式。如果不重写该方法,运行时将把方法调用的所有细节封装到NSInvocation对象中,进入完整的消息转发机制中,运行时将继续调用一下方法来进行消息的派发:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
- (void)forwardInvocation:(NSInvocation *)anInvocation

方法中的NSInvocation参数,包含了所有方法调用的细节,包括selector/target/参数等,重写该方法后我们可以将anInvocation转发给多个对象来处理该消息。在该阶段我们可以用来实现 “多重继承” 或者多重代理等。

如果在两个阶段都不做任何处理的话,运行时将会把selector交由doesNotRecognizeSelector方法来处理,从而抛出异常导致crash,异常信息如下:

-[*** ***]:unrecognized selector sent to instance 0x*****

补充:消息转发阶段两种方式的区别

第一种:
- (id)forwardingTargetForSelector:(SEL)aSelector

第二种:
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
- (void)forwardInvocation:(NSInvocation *)anInvocation

第一种只能算是重定向消息的接收对象吧,并且只能给某个特定的对象,不算是真正意义上的消息转发;

第二种首先调用methodSignatureForSelector:方法来获取函数的参数和返回值,如果返回为nil,程序会Crash掉,并抛出’unrecognized selector sent to instance’异常信息。如果返回了一个函数签名,系统就会创建一个NSInvocation对象并调用-forwardInvocation:方法。并且在forwardInvocation方法中可以选择处理或不处理,有两种方式:1.手动转发消息(invokeWithTarget) 或 2.抛出异常(doesNotRecognizeSelector),而且比较灵活,灵活之处有:1.可以将不同的消息转发给不同的对象 2.可以屏蔽外界传入的参数值,只在内部给参数传值 3.可以增加或减少原对象函数的参数

3.消息转发的实践

研究技术说到底还是实用为王,日常中的一些crash有时还是可以规避的。简单做了一个场景应用,就是在低版本的机器上运行高版本的代码很容易产生异常,这也算是版本兼容的范畴。

具体的例子就是在使用scrollView来做页面时,常常需要设置self.automaticallyAdjustsScrollViewInsets = NO;禁用掉自动设置的内边距来确保页面内容不会自动偏移。但是在iOS 11.0后,这个属性被废弃掉了,取而代之的是self.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;。当机器跑在不同的系统版本下时,就可能出现该设置无效,还有可能是闪退的情况:

当我经模拟器切换至iOS 10.0的版本时,闪退出现了,异常信息是’-[UIScrollView setContentInsetAdjustmentBehavior:]: unrecognized selector sent to instance 0x7f867a80fe00’,怎么解决呢?一行行的去改代码也是可以的,但这是不理智的,当项目中的页面很多时这就很耗时了,而且容易疏漏。还有一种办法,就是利用消息的转发机制,在运行时让合适的消息选择合适的对象进行发送,这样问题就很好解决了。

具体的代码实现在Demo里。

至此,算是初步的了解了OC的动态性吧,更深入的还有待学习。